Micron Document
πŸŽ–οΈGitΠ―Ρ€Π°πŸŽ–οΈ

Commit 4e48e64e786b58d7c73280f7aa6ebf55be9e53e5


Parents : bc5719c
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-11T18:40:17-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-11T23:40:17Z

fix(database): make completed cross-transport merges retry-idempotent (#6231)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Changes
Diff

diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json
new file mode 100644
index 0000000000..82c0182976
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/49.json
@@ -0,0 +1,1732 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 49,
+ "identityHash": "a1b84a9235210248ec3b93dfa1faddbd",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "powerChannelLabels",
+ "columnName": "power_channel_labels",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'[]'"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "signsPackets",
+ "columnName": "has_xeddsa_signed",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "translatedText",
+ "columnName": "translated_text",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "showTranslated",
+ "columnName": "show_translated",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "targets",
+ "columnName": "targets",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovery_session",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetsScanned",
+ "columnName": "presets_scanned",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "homePreset",
+ "columnName": "home_preset",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "totalUniqueNodes",
+ "columnName": "total_unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalMessages",
+ "columnName": "total_messages",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "totalSensorPackets",
+ "columnName": "total_sensor_packets",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "furthestNodeDistance",
+ "columnName": "furthest_node_distance",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "completionStatus",
+ "columnName": "completion_status",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'complete'"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "userLatitude",
+ "columnName": "user_latitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "userLongitude",
+ "columnName": "user_longitude",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "totalDwellSeconds",
+ "columnName": "total_dwell_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "deviceAddress",
+ "columnName": "device_address",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "homeLoraConfig",
+ "columnName": "home_lora_config",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "homePrimaryChannel",
+ "columnName": "home_primary_channel",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "discovery_preset_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "sessionId",
+ "columnName": "session_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetName",
+ "columnName": "preset_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "dwellDurationSeconds",
+ "columnName": "dwell_duration_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uniqueNodes",
+ "columnName": "unique_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "directNeighborCount",
+ "columnName": "direct_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "meshNeighborCount",
+ "columnName": "mesh_neighbor_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "infrastructureNodeCount",
+ "columnName": "infrastructure_node_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "avgChannelUtilization",
+ "columnName": "avg_channel_utilization",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "avgAirtimeRate",
+ "columnName": "avg_airtime_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetSuccessRate",
+ "columnName": "packet_success_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "packetFailureRate",
+ "columnName": "packet_failure_rate",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0.0"
+ },
+ {
+ "fieldPath": "aiSummary",
+ "columnName": "ai_summary",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "numPacketsTx",
+ "columnName": "num_packets_tx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRx",
+ "columnName": "num_packets_rx",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numPacketsRxBad",
+ "columnName": "num_packets_rx_bad",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numRxDupe",
+ "columnName": "num_rx_dupe",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelay",
+ "columnName": "num_tx_relay",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTxRelayCanceled",
+ "columnName": "num_tx_relay_canceled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numOnlineNodes",
+ "columnName": "num_online_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "numTotalNodes",
+ "columnName": "num_total_nodes",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "uptimeSeconds",
+ "columnName": "uptime_seconds",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovery_preset_result_session_id",
+ "unique": false,
+ "columnNames": [
+ "session_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_session",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "session_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "discovered_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "presetResultId",
+ "columnName": "preset_result_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "neighborType",
+ "columnName": "neighbor_type",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "'direct'"
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "distanceFromUser",
+ "columnName": "distance_from_user",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "hopCount",
+ "columnName": "hop_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageCount",
+ "columnName": "message_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sensorPacketCount",
+ "columnName": "sensor_packet_count",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isInfrastructure",
+ "columnName": "is_infrastructure",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_discovered_node_preset_result_id",
+ "unique": false,
+ "columnNames": [
+ "preset_result_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)"
+ },
+ {
+ "name": "index_discovered_node_node_num",
+ "unique": false,
+ "columnNames": [
+ "node_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "discovery_preset_result",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "preset_result_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "event_firmware_edition",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))",
+ "fields": [
+ {
+ "fieldPath": "edition",
+ "columnName": "edition",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "welcomeMessage",
+ "columnName": "welcome_message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "eventStart",
+ "columnName": "event_start",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "eventEnd",
+ "columnName": "event_end",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "timeZone",
+ "columnName": "time_zone",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "location",
+ "columnName": "location",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "iconUrl",
+ "columnName": "icon_url",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "accentColor",
+ "columnName": "accent_color",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "tag",
+ "columnName": "tag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "domain",
+ "columnName": "domain",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "themeJson",
+ "columnName": "theme_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareJson",
+ "columnName": "firmware_json",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "linksJson",
+ "columnName": "links_json",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "edition"
+ ]
+ }
+ },
+ {
+ "tableName": "merge_marker",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))",
+ "fields": [
+ {
+ "fieldPath": "sourceDbName",
+ "columnName": "source_db_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mergedAt",
+ "columnName": "merged_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "source_db_name"
+ ]
+ }
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1b84a9235210248ec3b93dfa1faddbd')"
+ ]
+ }
+}
\ No newline at end of file

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index 46c040b6fc..e98b40705a 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -25,6 +25,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
@@ -45,6 +46,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.normalizeAddress
@@ -66,6 +68,16 @@ open class DatabaseManager(
private val managerScope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val mutex = Mutex()
+ // Per-source write barrier for merges. `withDb` deliberately does NOT take [mutex] (hot path), so a merge under
+ // [mutex] must still drain any in-flight writer that captured the source DB before folding it away β€” otherwise a
+ // late-committing write is lost when the source is retired. This dedicated lock (never held across a drain await,
+ // so it can't deadlock the merge) tracks live `withDb` blocks per captured DB instance. It also guards the merge's
+ // active-DB swap so a writer either registers against `source` before the swap and is drained, or captures `dest`
+ // after it and is safe β€” it can never slip through the gap between the two.
+ private val writerTrackerMutex = Mutex()
+ private val activeWriters = mutableMapOf<MeshtasticDatabase, Int>()
+ private val drainWaiters = mutableMapOf<MeshtasticDatabase, MutableList<CompletableDeferred<Unit>>>()
+
private val cacheLimitKey = intPreferencesKey(DatabaseConstants.CACHE_LIMIT_KEY)
private val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
@@ -235,22 +247,23 @@ open class DatabaseManager(
val source = _currentDb.value ?: return@withLock
val dest = withContext(dispatchers.io) { getOrOpenDatabase(claimed) }
- // Redirect live writers to the canonical DB BEFORE merging. Otherwise a concurrent withDb
- // write (connect triggers a full NodeDB re-dump) could land in `source` after the merge has
- // already snapshotted it, then be lost when `source` is retired β€” and withDb's closed-pool
- // retry can't recover it because the write itself succeeded. New writers now capture `dest`;
- // any still holding `source` are covered by that retry once retireDatabase closes it.
- _currentDb.value = dest
- currentDbName = claimed
+ // Redirect live writers to the canonical DB BEFORE merging (a connect triggers a full NodeDB
+ // re-dump). The swap is published under the writer lock so a concurrent withDb write registers
+ // against `source` before it β€” and is drained below β€” or captures `dest` after it and is safe.
+ // New writers now capture `dest`; drainWriters then waits out any still writing to `source` so
+ // the merge can't snapshot `source` mid-write and lose it when `source` is later retired.
+ publishActiveDb(dest, claimed)
try {
- withContext(dispatchers.io) { DatabaseMerger.merge(source, dest) }
+ withContext(dispatchers.io) {
+ drainWriters(source, sourceName)
+ DatabaseMerger.merge(source, dest, sourceName)
+ }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// merge() is atomic, so on failure `dest` is unchanged. Roll the active DB back to
// `source` so the address still resolves consistently and the merge retries next connect.
- _currentDb.value = source
- currentDbName = sourceName
+ publishActiveDb(source, sourceName)
Logger.w(e) {
"Merge into ${anonymizeDbName(claimed)} failed; kept ${anonymizeDbName(sourceName)} active"
}
@@ -370,9 +383,57 @@ open class DatabaseManager(
}
}
+ /** Atomically snapshots the active DB and registers a writer against it. Returns null if none is open. */
+ private suspend fun beginWrite(): MeshtasticDatabase? = writerTrackerMutex.withLock {
+ val db = _currentDb.value ?: return@withLock null
+ activeWriters[db] = (activeWriters[db] ?: 0) + 1
+ db
+ }
+
+ /**
+ * Registers a writer against a specific [db] β€” used by the withDb retry paths, whose target is a recovered/new
+ * instance rather than the snapshotted active DB, so their writes stay visible to a concurrent drain too.
+ */
+ private suspend fun registerWriter(db: MeshtasticDatabase) =
+ writerTrackerMutex.withLock { activeWriters[db] = (activeWriters[db] ?: 0) + 1 }
+
+ /** Deregisters a writer and releases any merge waiting for [db] to quiesce. Cancellation-safe (see call site). */
+ private suspend fun endWrite(db: MeshtasticDatabase) = writerTrackerMutex.withLock {
+ val remaining = (activeWriters[db] ?: 1) - 1
+ if (remaining <= 0) {
+ activeWriters.remove(db)
+ drainWaiters.remove(db)?.forEach { it.complete(Unit) }
+ } else {
+ activeWriters[db] = remaining
+ }
+ }
+
+ /** Publishes [db]/[name] as active under the writer lock so concurrent [beginWrite]s order against the swap. */
+ private suspend fun publishActiveDb(db: MeshtasticDatabase, name: String) = writerTrackerMutex.withLock {
+ _currentDb.value = db
+ currentDbName = name
+ }
+
+ /**
+ * Suspends until every writer that captured [db] before this call has finished, so a merge never snapshots [db]
+ * while a write is still in flight (and then loses it when [db] is retired). Bounded by [WRITER_DRAIN_TIMEOUT_MS]
+ * so a wedged writer can't pin the merge β€” and [mutex] β€” forever; falling through on timeout is no worse than the
+ * old barrier-less behavior for that rare case.
+ */
+ private suspend fun drainWriters(db: MeshtasticDatabase, dbName: String) {
+ val waiter =
+ writerTrackerMutex.withLock {
+ if ((activeWriters[db] ?: 0) == 0) return
+ CompletableDeferred<Unit>().also { drainWaiters.getOrPut(db) { mutableListOf() }.add(it) }
+ }
+ if (withTimeoutOrNull(WRITER_DRAIN_TIMEOUT_MS) { waiter.await() } == null) {
+ Logger.w { "Timed out draining writers on ${anonymizeDbName(dbName)} before merge" }
+ }
+ }
+
@Suppress("ReturnCount", "ThrowsCount", "TooGenericExceptionCaught", "CyclomaticComplexMethod")
private suspend fun <T> withCurrentDb(block: suspend (MeshtasticDatabase) -> T): T? {
- val db = _currentDb.value ?: return null
+ val db = beginWrite() ?: return null
val active = currentDbName
markLastUsed(active)
try {
@@ -385,14 +446,7 @@ open class DatabaseManager(
val retryDb = _currentDb.value
if (retryDb != null && retryDb !== db && isDbClosedException(e)) {
Logger.w { "withDb: database closed during switch (${e.message}), retrying with current DB" }
- return try {
- runCancellableDbBlock(retryDb, block)
- } catch (retryCancel: CancellationException) {
- throw retryCancel
- } catch (retryEx: Exception) {
- retryEx.addSuppressed(e)
- throw retryEx
- }
+ return retryRegisteredDbBlock(retryDb, e, block)
}
// Same active DB but Room's connection pool is wedged β€” reopen onto a fresh active instance once.
@@ -406,17 +460,40 @@ open class DatabaseManager(
"withDb: active DB switched during timeout recovery; retrying with current DB"
}
}
- return try {
- runCancellableDbBlock(recoveredDb, block)
- } catch (retryCancel: CancellationException) {
- throw retryCancel
- } catch (retryEx: Exception) {
- retryEx.addSuppressed(e)
- throw retryEx
- }
+ return retryRegisteredDbBlock(recoveredDb, e, block)
}
throw e
+ } finally {
+ // NonCancellable so a cancelled withDb still deregisters β€” a leaked +1 would make every future
+ // drain on this DB instance time out.
+ withContext(NonCancellable) { endWrite(db) }
+ }
+ }
+
+ /**
+ * Retries [block] against [db] β€” the recovered/new instance a withDb retry targets instead of the DB it originally
+ * registered against. Registers a writer on [db] for the duration so the retry write stays visible to a concurrent
+ * merge draining [db], with the same NonCancellable deregistration guarantee as [withCurrentDb]'s outer
+ * registration (which remains held on the original DB until that finally runs β€” the overlap is harmless, counts
+ * balance per instance). Any retry failure carries the original failure [cause] as a suppressed exception.
+ */
+ @Suppress("TooGenericExceptionCaught")
+ private suspend fun <T> retryRegisteredDbBlock(
+ db: MeshtasticDatabase,
+ cause: Exception,
+ block: suspend (MeshtasticDatabase) -> T,
+ ): T {
+ registerWriter(db)
+ try {
+ return runCancellableDbBlock(db, block)
+ } catch (retryCancel: CancellationException) {
+ throw retryCancel
+ } catch (retryEx: Exception) {
+ retryEx.addSuppressed(cause)
+ throw retryEx
+ } finally {
+ withContext(NonCancellable) { endWrite(db) }
}
}
@@ -439,6 +516,11 @@ open class DatabaseManager(
internal companion object {
private const val BACKFILL_COLD_START_DELAY_MS = 2_000L
private const val WITH_DB_SLOW_OPERATION_MS = 1_000L
+
+ /**
+ * Upper bound on how long a merge waits for in-flight writers on the source DB to drain (see [drainWriters]).
+ */
+ private const val WRITER_DRAIN_TIMEOUT_MS = 5_000L
val DB_TERMS = listOf("pool", "database", "connection", "sqlite")
private const val ROOM_POOL_ACQUIRE_TIMEOUT_PHRASE = "timed out attempting to acquire"

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
index 41b107dd8c..313f9eaaf7 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt
@@ -19,6 +19,8 @@ package org.meshtastic.core.database
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import co.touchlab.kermit.Logger
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.database.entity.MergeMarkerEntity
/**
* Folds the contents of one device database ([source]) into another ([dest]) when both turn out to belong to the same
@@ -36,15 +38,26 @@ import co.touchlab.kermit.Logger
*/
object DatabaseMerger {
- suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
+ /**
+ * Folds [source] into [dest], skipping the work if [sourceName] was already merged into [dest]. [sourceName] is the
+ * source database's file name β€” the stable key under which the merge is recorded (see [MergeMarkerEntity]).
+ */
+ suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase, sourceName: String) {
// All destination writes run in a single transaction so a crash or exception mid-merge rolls
- // back cleanly instead of leaving `dest` half-merged. This also makes a retried merge safe: if
- // associateDevice re-runs (e.g. a crash before the address alias is persisted), the rolled-back
- // partial writes never happened, so the fresh-id packet/discovery re-inserts can't duplicate.
- // Reads from `source` use its own connection pool and don't participate in this transaction.
+ // back cleanly instead of leaving `dest` half-merged. The merge marker is written inside that
+ // same transaction, so it commits atomically with the copied rows: on a retried merge (e.g. a
+ // crash after commit but before associateDevice persisted the address alias) the marker is already
+ // present and the whole merge is skipped, so the fresh-id packet/discovery re-inserts β€” which are
+ // NOT idempotent on their own β€” can never duplicate. Reads from `source` use its own connection
+ // pool and don't participate in this transaction.
var packets = 0
+ var skipped = false
dest.useWriterConnection { transactor ->
transactor.immediateTransaction {
+ if (dest.mergeMarkerDao().isMerged(sourceName)) {
+ skipped = true
+ return@immediateTransaction
+ }
packets = mergePackets(source, dest)
mergeReactions(source, dest)
mergeContactSettings(source, dest)
@@ -55,9 +68,14 @@ object DatabaseMerger {
mergeLogs(source, dest)
mergeTraceroutePositions(source, dest)
mergeDiscovery(source, dest)
+ dest.mergeMarkerDao().insertMarker(MergeMarkerEntity(sourceDbName = sourceName, mergedAt = nowMillis))
}
}
- Logger.i { "Merged $packets packets across transports into unified node DB" }
+ if (skipped) {
+ Logger.i { "Source ${anonymizeDbName(sourceName)} already merged; skipped duplicate merge" }
+ } else {
+ Logger.i { "Merged $packets packets across transports into unified node DB" }
+ }
}
/**

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 73076e4e7a..a7e2373695 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -30,6 +30,7 @@ import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.dao.EventFirmwareEditionDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
+import org.meshtastic.core.database.dao.MergeMarkerDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.database.dao.PacketDao
@@ -43,6 +44,7 @@ import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.database.entity.EventFirmwareEditionEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
+import org.meshtastic.core.database.entity.MergeMarkerEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
@@ -73,6 +75,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DiscoveryPresetResultEntity::class,
DiscoveredNodeEntity::class,
EventFirmwareEditionEntity::class,
+ MergeMarkerEntity::class,
],
autoMigrations =
[
@@ -121,8 +124,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 45, to = 46),
AutoMigration(from = 46, to = 47),
AutoMigration(from = 47, to = 48),
+ AutoMigration(from = 48, to = 49),
],
- version = 48,
+ version = 49,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -149,6 +153,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun eventFirmwareEditionDao(): EventFirmwareEditionDao
+ abstract fun mergeMarkerDao(): MergeMarkerDao
+
companion object {
/**
* Configures a [RoomDatabase.Builder] with standard settings for this project.

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt
new file mode 100644
index 0000000000..5813384a27
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MergeMarkerDao.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.dao
+
+import androidx.room3.Dao
+import androidx.room3.Insert
+import androidx.room3.OnConflictStrategy
+import androidx.room3.Query
+import org.meshtastic.core.database.entity.MergeMarkerEntity
+
+/** Tracks which source databases have already been merged into this (canonical) database. See [MergeMarkerEntity]. */
+@Dao
+interface MergeMarkerDao {
+
+ /** IGNORE keeps the earliest marker on a re-run; the row's mere existence is what matters, not its timestamp. */
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertMarker(marker: MergeMarkerEntity)
+
+ @Query("SELECT EXISTS(SELECT 1 FROM merge_marker WHERE source_db_name = :sourceDbName)")
+ suspend fun isMerged(sourceDbName: String): Boolean
+}

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt
new file mode 100644
index 0000000000..4493ea0208
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MergeMarkerEntity.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+
+/**
+ * A durable record, in the canonical (destination) database, that a given source database has already been folded in by
+ * [org.meshtastic.core.database.DatabaseMerger]. Written inside the same merge transaction as the copied rows, so it
+ * commits atomically with them. When [org.meshtastic.core.database.DatabaseManager.associateDevice] re-runs the merge
+ * on the next connection, `DatabaseMerger.merge` finds the marker at the top of its transaction and skips the whole
+ * merge β€” closing the crash window between the merge commit and the datastore alias write, where a retry would
+ * otherwise duplicate the fresh-id packet/discovery rows. Markers share the destination DB's lifecycle: if the
+ * canonical DB is LRU-evicted and later recreated empty, its markers vanish with the merged data they describe.
+ */
+@Entity(tableName = "merge_marker")
+data class MergeMarkerEntity(
+ @PrimaryKey @ColumnInfo(name = "source_db_name") val sourceDbName: String,
+ @ColumnInfo(name = "merged_at") val mergedAt: Long = 0L,
+)

diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt
index e91ac184f1..a8fb3b4824 100644
--- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DatabaseMergerTest.kt
@@ -52,6 +52,9 @@ class DatabaseMergerTest {
// Same physical node reached over two transports β†’ same myNodeNum in both DBs.
private val nodeNum = 0x1234
+ // The source DB's file name β€” the key under which a completed merge is recorded in `dest`.
+ private val sourceName = "meshtastic_database_source"
+
private val source: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
private val dest: MeshtasticDatabase = getInMemoryDatabaseBuilder().build()
@@ -171,7 +174,7 @@ class DatabaseMergerTest {
.discoveryDao()
.insertDiscoveredNodes(listOf(DiscoveredNodeEntity(presetResultId = srcPreset, nodeNum = 30L)))
- DatabaseMerger.merge(source, dest)
+ DatabaseMerger.merge(source, dest, sourceName)
// Packets: 1 + 2, with fresh distinct non-zero uuids (no collision with dest's existing uuid).
val packets = dest.packetDao().getAllPacketsSnapshot()
@@ -220,4 +223,51 @@ class DatabaseMergerTest {
"node appended under rewired preset id",
)
}
+
+ /**
+ * Regression for #6230: a crash after the merge transaction commits but before the address alias is persisted
+ * re-runs [DatabaseMerger.merge] on the next connection. The non-idempotent tables (packets, discovery) must not be
+ * duplicated. The durable per-source merge marker, written inside the merge transaction, makes the second merge a
+ * no-op.
+ */
+ @Test
+ fun mergeIsIdempotentAcrossRetry() = runTest {
+ source.nodeInfoDao().setMyNodeInfo(myNode())
+ source.packetDao().insert(textPacket("0!broadcast", "srcmsgone", time = 200))
+ source.packetDao().insert(textPacket("0!broadcast", "srcmsgtwo", time = 300))
+ val srcSession =
+ source
+ .discoveryDao()
+ .insertSession(
+ DiscoverySessionEntity(timestamp = 1, presetsScanned = "LongFast", homePreset = "LongFast"),
+ )
+ val srcPreset =
+ source
+ .discoveryDao()
+ .insertPresetResult(DiscoveryPresetResultEntity(sessionId = srcSession, presetName = "LongFast"))
+ source
+ .discoveryDao()
+ .insertDiscoveredNodes(listOf(DiscoveredNodeEntity(presetResultId = srcPreset, nodeNum = 30L)))
+
+ DatabaseMerger.merge(source, dest, sourceName)
+
+ val packetsAfterFirst = dest.packetDao().getAllPacketsSnapshot().size
+ val sessionsAfterFirst = dest.discoveryDao().getAllSessionsSnapshot().size
+ assertEquals(2, packetsAfterFirst, "both source packets merged once")
+ assertEquals(1, sessionsAfterFirst, "source discovery session merged once")
+
+ // Simulate the crash-window retry: same source folded into the same dest again.
+ DatabaseMerger.merge(source, dest, sourceName)
+
+ assertEquals(
+ packetsAfterFirst,
+ dest.packetDao().getAllPacketsSnapshot().size,
+ "packets not duplicated on retry",
+ )
+ assertEquals(
+ sessionsAfterFirst,
+ dest.discoveryDao().getAllSessionsSnapshot().size,
+ "discovery sessions not duplicated on retry",
+ )
+ }
}

Served by rngit 1.5.2 - Generated in 0.2s